레시피 공유 페이지 만들기

✒️ 2025-07-08 21:53 내용 수정


실습 목표

  1. Spring Boot를 사용해서 레시피를 공유하는 사이트를 제작한다.
  2. 페이지 구성
    1. 목록 페이지
    2. 상세 페이지
    3. 등록 페이지
    4. 인증 페이지
  3. 핵심 기능
    1. 사용자 인증 시스템 : 회원 가입, 로그인/로그아웃, 접근 제어
    2. 레시피 관리 : 등록, 목록, 상세 보기, 검색
    3. 상호작용 기능 : 좋아요, 댓글, 태그
    4. 반응형 디자인, 에러 처리

프로젝트 설정

의존성

<!-- spring web -->
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-web</artifactId>  
</dependency>  
<!-- thymeleaf -->
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-thymeleaf</artifactId>  
</dependency>  
<!-- jpa -->
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-data-jpa</artifactId>  
</dependency>  
<!-- validation -->
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-validation</artifactId>  
</dependency>  
<!-- h2 -->
<dependency>  
    <groupId>com.h2database</groupId>  
    <artifactId>h2</artifactId>  
    <scope>runtime</scope>  
</dependency>

디렉터리 구조

spring_day6/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   ├── com/
│   │   │       ├── example/
│   │   │           ├── spring_day6/
│   │   │               ├── controller/
│   │   │               │   ├── AuthController.java
│   │   │               │   ├── HomeController.java
│   │   │               │   └── RecipeController.java
│   │   │               ├── model/
│   │   │               │   ├── Comment.java
│   │   │               │   ├── Recipe.java
│   │   │               │   ├── Tag.java
│   │   │               │   └── User.java
│   │   │               ├── repository/
│   │   │               │   ├── CommentRepository.java
│   │   │               │   ├── RecipeRepository.java
│   │   │               │   ├── TagRepository.java
│   │   │               │   └── UserRepository.java
│   │   │               ├── service/
│   │   │               │   ├── AuthService.java
│   │   │               │   ├── AuthServiceImpl.java
│   │   │               │   ├── CommentService.java
│   │   │               │   ├── CommentServiceImpl.java
│   │   │               │   ├── RecipeService.java
│   │   │               │   ├── RecipeServiceImpl.java
│   │   │               │   ├── TagService.java
│   │   │               │   └── TagServiceImpl.java
│   │   │               └── SpringDay6Application.java
│   │   ├── resources/
│   │       ├── templates/
│   │       │   ├── index.html
│   │       │   ├── login.html
│   │       │   ├── recipe_detail.html
│   │       │   ├── recipe_form.html
│   │       │   ├── recipes.html
│   │       │   └── register.html
│   │       └── application.properties
│
├── uploads/
├── mvnw
├── mvnw.cmd
└── pom.xml

MVC 패턴

Model

Entity - User

package com.example.spring_day6.model;  
  
import jakarta.persistence.*;  
import java.util.List;  
import java.util.Set;  
import java.util.Objects;  
  
@Entity  
public class User {  
    @Id @GeneratedValue  
    private Long id;  
    private String username;  
    private String password;  

	// Comment에 있는 user 필드로 매핑됨
    @OneToMany(mappedBy = "user")  
    private List<Comment> comments;  
  
    @ManyToMany  
    private Set<Recipe> favorites;  
  
    public User() {}  
  
    public User(String username, String password) {  
        this.username = username;  
        this.password = password;  
    }  

	// getter와 setter

    // equals와 hashCode 추가 (Set에서 중복 제거를 위해 필요)  
    @Override  
    public boolean equals(Object o) {  
        // 두 User 객체 비교 시 같으면 true
        if (this == o) return true;  
        // 객체가 비어있거나 클래스가 다르면 false
        if (o == null || getClass() != o.getClass()) return false;  
        User user = (User) o;  
        // 두 객체의 사용자 이름이 같은지 확인
        return Objects.equals(username, user.username);  
    }  
  
    @Override  
    public int hashCode() {  
        return Objects.hash(username);  
    }  
}

Entity - Recipe

package com.example.spring_day6.model;  
  
import jakarta.persistence.*;  
import java.time.LocalDateTime;  
import java.util.List;  
import java.util.Set;  
  
@Entity  
public class Recipe {  
    @Id  
    @GeneratedValue    
    private Long id;  
    private String title;  
    @Column(length = 5000)  
    private String description;  
    private LocalDateTime createdAt;  
  
    @ManyToOne  
    private User author; // 작성자 추가  
  
    @OneToMany(mappedBy = "recipe")  
    private List<Comment> comments;  
  
    @ManyToMany  
    private Set<Tag> tags;  
  
    @ManyToMany  
    private Set<User> likes;  
  
    public Recipe() {}  
  
    // getter와 setter
}

Entity - Comment

package com.example.spring_day6.model;  
  
import jakarta.persistence.*;  
import java.time.LocalDateTime;  
  
@Entity  
public class Comment {  
    @Id  
    @GeneratedValue    
    private Long id;  
  
    @ManyToOne  
    private Recipe recipe;  
  
    @ManyToOne  
    private User user;  
  
    @Column(length = 1000)  
    private String content;  
  
    private LocalDateTime createdAt;  
  
    public Comment() {}  
  
    // getter와 setter
}

Entity - Tag

package com.example.spring_day6.model;  
  
import jakarta.persistence.*;  
import java.util.Set;  
  
@Entity  
public class Tag {  
    @Id  
    @GeneratedValue    
    private Long id;  
    private String name;  
  
    @ManyToMany(mappedBy = "tags")  
    private Set<Recipe> recipes;  
  
    public Tag() {}  
  
    // getter와 setter
}

repository

UserRepository

package com.example.spring_day6.repository;  
  
import com.example.spring_day6.model.User;  
import org.springframework.data.jpa.repository.JpaRepository;  
  
public interface UserRepository extends JpaRepository<User, Long> {  
    // 인메모리 방식으로 변경했으므로 실제로는 사용하지 않음  
}

RecipeRepository

package com.example.spring_day6.repository;  
  
import com.example.spring_day6.model.Recipe;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.Pageable;  
import org.springframework.data.jpa.repository.JpaRepository;  
  
public interface RecipeRepository extends JpaRepository<Recipe, Long> {  
    Page<Recipe> findByTitleContaining(String keyword, Pageable pageable);  
}

CommentRepository

package com.example.spring_day6.repository;  
  
import com.example.spring_day6.model.Comment;  
import org.springframework.data.jpa.repository.JpaRepository;  
  
public interface CommentRepository extends JpaRepository<Comment, Long> {  
}

TagRepository

package com.example.spring_day6.repository;  
  
import com.example.spring_day6.model.Tag;  
import org.springframework.data.jpa.repository.JpaRepository;  
  
public interface TagRepository extends JpaRepository<Tag, Long> {  
    Tag findByName(String name);  
}

Service

AuthService

  1. AuthService
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.User;  
  
public interface AuthService {  
    User register(String username, String password);  
    User login(String username, String password);  
}
  1. AuthServiceImpl
    • 인메모리 방식으로 구현하기 위해 Service 내에 사용자 목록을 저장할 Map을 생성하고, Map에 저장된 사용자 정보를 읽어오거나 사용자를 추가한다.
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.User;  
import org.springframework.stereotype.Service;  
  
import java.util.HashMap;  
import java.util.Map;  
  
@Service  
public class AuthServiceImpl implements AuthService {  
	// DB 대신 Service에 사용자 목록을 저장한 Map을 활용
    private final Map<String, User> users = new HashMap<>();  
    // ID 역할
    private Long nextId = 1L;  

	// 가입
    @Override  
    public User register(String username, String password) {  
	    // Map에 저장된 사용자가 있는지 확인
        if (users.containsKey(username)) {  
            throw new RuntimeException("Username already exists");  
        }  

		// 사용자 객체를 사용자 이름과 비밀번호로 생성
        User user = new User(username, password);  
        user.setId(nextId++); // ID 자동 증가
        // 맵에 추가
        users.put(username, user);  
        return user;  
    }  

	// 로그인
    @Override  
    public User login(String username, String password) {  
        // 저장된 사용자 중 
        User user = users.get(username);  
        if (user != null && user.getPassword().equals(password)) {  
            return user;  
        }  
        throw new RuntimeException("Invalid credentials");  
    }  
}

RecipeRepository

  1. RecipeService
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Recipe;  
import com.example.spring_day6.model.User;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.Pageable;  
  
public interface RecipeService {  
    Page<Recipe> findAll(Pageable pageable);  
    Page<Recipe> findByTitleContaining(String keyword, Pageable pageable);  
    Recipe findById(Long id);  
    Recipe save(Recipe recipe, String tags, User author);  
    void toggleLike(Long recipeId, User user);  
}
  1. RecipeServiceImpl
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Recipe;  
import com.example.spring_day6.model.Tag;  
import com.example.spring_day6.model.User;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.PageImpl;  
import org.springframework.data.domain.Pageable;  
import org.springframework.stereotype.Service;  
  
import java.util.*;  
import java.util.stream.Collectors;  
  
@Service  
public class RecipeServiceImpl implements RecipeService {  
	// 인메모리에서 메모리를 저장
    private final Map<Long, Recipe> recipes = new HashMap<>();  
    private Long nextId = 1L;  
  
    @Autowired  
    private TagService tagService;  

	// 전체 조회
    @Override  
    public Page<Recipe> findAll(Pageable pageable) {  
        List<Recipe> allRecipes = new ArrayList<>(recipes.values());  
        return createPage(allRecipes, pageable);  
    }  

	// 제목 기준으로 검색
    @Override  
    public Page<Recipe> findByTitleContaining(String keyword, Pageable pageable) {  
        List<Recipe> filteredRecipes = recipes.values().stream()  
                .filter(recipe -> recipe.getTitle().toLowerCase().contains(keyword.toLowerCase()))  
                .collect(Collectors.toList());  
        return createPage(filteredRecipes, pageable);  
    }  
  
    private Page<Recipe> createPage(List<Recipe> allRecipes, Pageable pageable) {  
        // 최신순으로 정렬 (ID 역순)  
        allRecipes.sort((r1, r2) -> r2.getId().compareTo(r1.getId()));  
		// 페이지 시작 지점 설정
        int start = (int) pageable.getOffset();  
        // 페이지 종료 지점을 (시작+페이지 수)와 레시피 개수 중 최소값을 결정
        int end = Math.min(start + pageable.getPageSize(), allRecipes.size());  
  
        List<Recipe> pageContent;  
        if (start >= allRecipes.size()) {  
            pageContent = new ArrayList<>();  
        } else {  
            pageContent = allRecipes.subList(start, end);  
        }  
  
        return new PageImpl<>(pageContent, pageable, allRecipes.size());  
    }  
  
    @Override  
    public Recipe findById(Long id) {  
        Recipe recipe = recipes.get(id);  
        if (recipe == null) {  
            throw new RuntimeException("Recipe not found: " + id);  
        }  
        return recipe;  
    }  
  
    @Override  
    public Recipe save(Recipe recipe, String tags, User author) {  
        if (recipe.getId() == null) {  
            recipe.setId(nextId++);  
        }  
  
        // 작성자 설정  
        recipe.setAuthor(author);  
  
        // 태그 처리  
        Set<Tag> tagSet = new HashSet<>();  
        if (tags != null && !tags.trim().isEmpty()) {  
            String[] tagNames = tags.split(",");  
            for (String tagName : tagNames) {  
                tagName = tagName.trim();  
                if (!tagName.isEmpty()) {  
                    Tag tag = tagService.findByName(tagName);  
                    if (tag == null) {  
                        tag = new Tag();  
                        tag.setId((long) Math.abs(tagName.hashCode())); // 양수 ID 보장  
                        tag.setName(tagName);  
                        ((TagServiceImpl) tagService).saveTag(tag);  
                    }  
                    tagSet.add(tag);  
                }  
            }  
        }  
        recipe.setTags(tagSet);  
  
        // 초기화  
        if (recipe.getLikes() == null) {  
            recipe.setLikes(new HashSet<>());  
        }  
        if (recipe.getComments() == null) {  
            recipe.setComments(new ArrayList<>());  
        }  
  
        recipes.put(recipe.getId(), recipe);  
        return recipe;  
    }  
  
    @Override  
    public void toggleLike(Long recipeId, User user) {  
        Recipe recipe = findById(recipeId);  
        if (recipe.getLikes().contains(user)) {  
            recipe.getLikes().remove(user);  
        } else {  
            recipe.getLikes().add(user);  
        }  
    }  
}

CommentService

  1. CommentSerivce
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Comment;  
import com.example.spring_day6.model.User;  
  
public interface CommentService {  
    Comment addComment(Long recipeId, User user, String content);  
}
  1. CommentServiceImpl
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Comment;  
import com.example.spring_day6.model.Recipe;  
import com.example.spring_day6.model.User;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.stereotype.Service;  
  
import java.time.LocalDateTime;  
import java.util.HashMap;  
import java.util.Map;  
  
@Service  
public class CommentServiceImpl implements CommentService {  
  
    private final Map<Long, Comment> comments = new HashMap<>();  
    private Long nextId = 1L;  
  
    @Autowired  
    private RecipeService recipeService;  
  
    @Override  
    public Comment addComment(Long recipeId, User user, String content) {  
        Recipe recipe = recipeService.findById(recipeId);  
  
        Comment comment = new Comment();  
        comment.setId(nextId++);  
        comment.setRecipe(recipe);  
        comment.setUser(user);  
        comment.setContent(content);  
        comment.setCreatedAt(LocalDateTime.now());  
  
        comments.put(comment.getId(), comment);  
  
        // 레시피의 댓글 목록에 추가  
        recipe.getComments().add(comment);  
  
        return comment;  
    }  
}

TagService

  1. TagService
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Tag;  
import java.util.List;  
  
public interface TagService {  
    List<Tag> findAll();  
    Tag findByName(String name);  
}
  1. TagServiceImpl
package com.example.spring_day6.service;  
  
import com.example.spring_day6.model.Tag;  
import org.springframework.stereotype.Service;  
  
import java.util.*;  
  
@Service  
public class TagServiceImpl implements TagService {  
  
    private final Map<Long, Tag> tags = new HashMap<>();  
    private final Map<String, Tag> tagsByName = new HashMap<>();  
  
    @Override  
    public List<Tag> findAll() {  
        return new ArrayList<>(tags.values());  
    }  
  
    @Override  
    public Tag findByName(String name) {  
        return tagsByName.get(name);  
    }  
  
    // 내부적으로 사용할 메서드  
    public Tag saveTag(Tag tag) {  
        tags.put(tag.getId(), tag);  
        tagsByName.put(tag.getName(), tag);  
        return tag;  
    }  
}

Controller

HomeController

package com.example.spring_day6.controller;  
  
import org.springframework.stereotype.Controller;  
import org.springframework.web.bind.annotation.GetMapping;  
  
@Controller  
public class HomeController {  
    @GetMapping("/")  
    public String home() {  
        return "redirect:/recipes";  
    }  
}

AuthController

package com.example.spring_day6.controller;  
  
import com.example.spring_day6.model.User;  
import com.example.spring_day6.service.AuthService;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.stereotype.Controller;  
import org.springframework.ui.Model;  
import org.springframework.web.bind.annotation.*;  
import jakarta.servlet.http.HttpSession;  
  
@Controller  
public class AuthController {  
    @Autowired  
    private AuthService authService;  
  
    @GetMapping("/login")  
    public String loginForm() {  
        return "login";  
    }  
  
    @PostMapping("/login")  
    public String login(@RequestParam String username, @RequestParam String password,  
                        HttpSession session, Model model) {  
        try {  
            User user = authService.login(username, password);  
            session.setAttribute("user", user);  
            return "redirect:/recipes";  
        } catch (RuntimeException e) {  
            model.addAttribute("error", "아이디 또는 비밀번호가 잘못되었습니다.");  
            return "login";  
        }  
    }  
  
    @GetMapping("/register")  
    public String registerForm() {  
        return "register";  
    }  
  
    @PostMapping("/register")  
    public String register(@RequestParam String username, @RequestParam String password,  
                           Model model) {  
        try {  
            authService.register(username, password);  
            return "redirect:/login";  
        } catch (RuntimeException e) {  
            model.addAttribute("error", "이미 존재하는 사용자명입니다.");  
            return "register";  
        }  
    }  
  
    @GetMapping("/logout")  
    public String logout(HttpSession session) {  
        session.invalidate();  
        return "redirect:/recipes";  
    }  
}

RecipeController

package com.example.spring_day6.controller;  
  
import com.example.spring_day6.model.Recipe;  
import com.example.spring_day6.model.User;  
import com.example.spring_day6.service.RecipeService;  
import com.example.spring_day6.service.TagService;  
import com.example.spring_day6.service.CommentService;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.PageRequest;  
import org.springframework.data.domain.Pageable;  
import org.springframework.stereotype.Controller;  
import org.springframework.ui.Model;  
import org.springframework.web.bind.annotation.*;  
import jakarta.servlet.http.HttpSession;  
  
import java.time.LocalDateTime;  
import java.util.Collections;  
  
@Controller  
@RequestMapping("/recipes")  
public class RecipeController {  
  
    @Autowired  
    private RecipeService recipeService;  
  
    @Autowired  
    private TagService tagService;  
  
    @Autowired  
    private CommentService commentService;  
  
    @GetMapping  
    public String recipes(@RequestParam(defaultValue = "0") int page,  
                          @RequestParam(defaultValue = "") String keyword,  
                          @RequestParam(defaultValue = "") String tag,  
                          Model model,  
                          HttpSession session) {  
        try {  
            Pageable pageable = PageRequest.of(page, 4); // 페이지당 4개로 변경  
            Page<Recipe> recipePage;  
  
            if (!keyword.isEmpty()) {  
                recipePage = recipeService.findByTitleContaining(keyword, pageable);  
            } else {  
                recipePage = recipeService.findAll(pageable);  
            }  
  
            model.addAttribute("page", recipePage);  
            model.addAttribute("keyword", keyword);  
            model.addAttribute("tag", tag);  
            model.addAttribute("tags", tagService.findAll() != null ? tagService.findAll() : Collections.emptyList());  
            model.addAttribute("user", session.getAttribute("user")); // 로그인 상태 확인용  
        } catch (Exception e) {  
            // 에러 발생시 빈 페이지 처리  
            model.addAttribute("page", null);  
            model.addAttribute("keyword", keyword);  
            model.addAttribute("tag", tag);  
            model.addAttribute("tags", Collections.emptyList());  
            model.addAttribute("user", session.getAttribute("user"));  
        }  
        return "recipes";  
    }  
  
    @GetMapping("/{id}")  
    public String recipeDetail(@PathVariable Long id, Model model, HttpSession session) {  
        try {  
            Recipe recipe = recipeService.findById(id);  
            model.addAttribute("recipe", recipe);  
            model.addAttribute("user", session.getAttribute("user"));  
        } catch (Exception e) {  
            model.addAttribute("recipe", null);  
            model.addAttribute("user", session.getAttribute("user"));  
        }  
        return "recipe_detail";  
    }  
  
    @GetMapping("/new")  
    public String newRecipeForm(HttpSession session) {  
        User user = (User) session.getAttribute("user");  
        if (user == null) {  
            return "redirect:/login";  
        }  
        return "recipe_form";  
    }  
  
    @PostMapping  
    public String createRecipe(@RequestParam String title,  
                               @RequestParam String description,  
                               @RequestParam(defaultValue = "") String tags,  
                               HttpSession session) {  
        User user = (User) session.getAttribute("user");  
        if (user == null) {  
            return "redirect:/login";  
        }  
  
        try {  
            Recipe recipe = new Recipe();  
            recipe.setTitle(title != null ? title : "제목 없음");  
            recipe.setDescription(description != null ? description : "설명 없음");  
            recipe.setCreatedAt(LocalDateTime.now());  
  
            // 여기가 수정된 부분: user 파라미터 추가  
            recipeService.save(recipe, tags, user);  
        } catch (Exception e) {  
            // 에러 발생시에도 목록으로 리다이렉트  
        }  
        return "redirect:/recipes";  
    }  
  
    @PostMapping("/{id}/like")  
    public String toggleLike(@PathVariable Long id, HttpSession session) {  
        User user = (User) session.getAttribute("user");  
        if (user == null) {  
            return "redirect:/login";  
        }  
  
        try {  
            recipeService.toggleLike(id, user);  
        } catch (Exception e) {  
            // 에러 발생시에도 상세 페이지로 리다이렉트  
        }  
        return "redirect:/recipes/" + id;  
    }  
  
    @PostMapping("/{id}/comments")  
    public String addComment(@PathVariable Long id,  
                             @RequestParam String content,  
                             HttpSession session) {  
        User user = (User) session.getAttribute("user");  
        if (user == null) {  
            return "redirect:/login";  
        }  
  
        try {  
            if (content != null && !content.trim().isEmpty()) {  
                commentService.addComment(id, user, content);  
            }  
        } catch (Exception e) {  
            // 에러 발생시에도 상세 페이지로 리다이렉트  
        }  
        return "redirect:/recipes/" + id;  
    }  
}

view

index

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title>Home</title>  
    <meta http-equiv="refresh" content="0;url=/recipes"/>  
</head>  
<body></body>  
</html>

login

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title>로그인</title>  
    <style>        
	    body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }  
        .container { max-width: 400px; margin: 100px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }  
  
        h2 { text-align: center; margin-bottom: 30px; color: #333; }  
  
        .form-group { margin-bottom: 20px; }  
        .form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }  
        .form-group input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }  
  
        .btn-login { width: 100%; background: #007bff; color: white; border: none; padding: 12px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; margin-bottom: 15px; }  
        .btn-login:hover { background: #0056b3; }  
  
        .register-link { text-align: center; }  
        .register-link a { color: #007bff; text-decoration: none; }  
        .register-link a:hover { text-decoration: underline; }  
  
        .error-message { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #f5c6cb; }  
    </style>  
    <script th:if="${error}">  
        alert('[[${error}]]');  
    </script>  
</head>  
<body>  
<div class="container">  
    <h2>로그인</h2>  
  
    <div th:if="${error}" class="error-message" th:text="${error}">에러 메시지</div>  
    <form th:action="@{/login}" method="post">  
        <div class="form-group">  
            <label for="username">사용자명</label>  
            <input type="text" id="username" name="username" required/>  
        </div>  
  
        <div class="form-group">  
            <label for="password">비밀번호</label>  
            <input type="password" id="password" name="password" required/>  
        </div>  
  
        <button type="submit" class="btn-login">로그인</button>  
    </form>  
  
    <div class="register-link">  
        <a th:href="@{/register}">회원가입</a>  
    </div>  
</div>  
</body>  
</html>

register

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title>회원가입</title>  
    <style>        
	    body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }  
        .container { max-width: 400px; margin: 100px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }  
  
        h2 { text-align: center; margin-bottom: 30px; color: #333; }  
  
        .form-group { margin-bottom: 20px; }  
        .form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }  
        .form-group input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }  
  
        .btn-register { width: 100%; background: #28a745; color: white; border: none; padding: 12px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; margin-bottom: 15px; }  
        .btn-register:hover { background: #218838; }  
  
        .login-link { text-align: center; }  
        .login-link a { color: #007bff; text-decoration: none; }  
        .login-link a:hover { text-decoration: underline; }  
  
        .error-message { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #f5c6cb; }  
    </style>  
    <script th:if="${error}">  
        alert('[[${error}]]');  
    </script>  
</head>  
<body>  
<div class="container">  
    <h2>회원가입</h2>  
  
    <div th:if="${error}" class="error-message" th:text="${error}">에러 메시지</div>  
  
    <form th:action="@{/register}" method="post">  
        <div class="form-group">  
            <label for="username">사용자명</label>  
            <input type="text" id="username" name="username" required/>  
        </div>  
  
        <div class="form-group">  
            <label for="password">비밀번호</label>  
            <input type="password" id="password" name="password" required/>  
        </div>  
  
        <button type="submit" class="btn-register">회원가입</button>  
    </form>  
  
    <div class="login-link">  
        <a th:href="@{/login}">로그인으로 돌아가기</a>  
    </div>  
</div>  
</body>  
</html>

recipes

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title>레시피 공유 커뮤니티</title>  
    <style>        
    body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }  
        .container { max-width: 1000px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }  
  
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }  
        .header h1 { color: #333; margin: 0; }  
        .auth-info { display: flex; align-items: center; gap: 10px; }  
  
        .btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; font-size: 14px; }  
        .btn:hover { background: #f8f8f8; }  
        .btn-primary { background: #007bff; color: white; border-color: #007bff; }  
        .btn-primary:hover { background: #0056b3; }  
  
        .search-section { background: #f8f9fa; padding: 20px; border-radius: 4px; margin-bottom: 20px; }  
        .search-form { display: flex; gap: 10px; align-items: center; }  
        .search-input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }  
        .search-select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }  
  
        .new-recipe { text-align: center; margin-bottom: 30px; }  
        .btn-new { background: #28a745; color: white; padding: 12px 24px; border: none; border-radius: 4px; text-decoration: none; font-weight: bold; }  
        .btn-new:hover { background: #218838; }  
  
        .recipes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }  
        .recipe-card { border: 1px solid #ddd; border-radius: 4px; padding: 20px; background: white; }  
        .recipe-title { font-size: 1.2em; font-weight: bold; margin-bottom: 10px; color: #333; }  
        .recipe-meta { color: #666; font-size: 0.9em; margin-bottom: 15px; }  
        .recipe-actions { text-align: center; }  
  
        .pagination { text-align: center; margin-top: 30px; }  
        .pagination a { margin: 0 5px; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #333; }  
        .pagination a:hover { background: #f8f8f8; }  
  
        .no-recipes { text-align: center; padding: 40px; color: #666; }  
    </style>  
</head>  
<body>  
<div class="container">  
    <div class="header">  
        <h1>레시피 공유 커뮤니티</h1>  
        <div class="auth-info">  
            <div th:if="${user != null}">  
                <span>안녕하세요, <strong th:text="${user?.username}">사용자</strong>님!</span>  
                <a th:href="@{/logout}" class="btn">로그아웃</a>  
            </div>  
            <div th:if="${user == null}">  
                <a th:href="@{/login}" class="btn">로그인</a>  
                <a th:href="@{/register}" class="btn btn-primary">회원가입</a>  
            </div>  
        </div>  
    </div>  
  
    <div class="search-section">  
        <form th:action="@{/recipes}" method="get" class="search-form">  
            <input type="text" name="keyword" placeholder="레시피 제목으로 검색..." th:value="${keyword ?: ''}" class="search-input"/>  
            <select name="tag" class="search-select">  
                <option value="">모든 태그</option>  
                <option th:each="t : ${tags ?: {}}" th:value="${t?.name}" th:text="${t?.name}" th:selected="${t?.name == tag}"></option>  
            </select>  
            <button type="submit" class="btn btn-primary">검색</button>  
        </form>  
    </div>  
  
    <div class="new-recipe">  
        <a th:href="@{/recipes/new}" class="btn-new">새 레시피 등록</a>  
    </div>  
  
    <div th:if="${page == null || #lists.isEmpty(page.content)}" class="no-recipes">  
        <h3>등록된 레시피가 없습니다</h3>  
        <p>첫 번째 레시피를 등록해보세요!</p>  
    </div>  
  
    <div th:if="${page != null && !#lists.isEmpty(page.content)}" class="recipes-grid">  
        <div th:each="recipe : ${page.content}" class="recipe-card">  
            <h3 class="recipe-title" th:text="${recipe?.title ?: '제목 없음'}">레시피 제목</h3>  
            <div class="recipe-meta">  
                <span th:text="${recipe?.createdAt != null ? #temporals.format(recipe.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span>  
            </div>  
            <div class="recipe-actions">  
                <a th:href="@{/recipes/{id}(id=${recipe?.id})}" class="btn btn-primary">자세히 보기</a>  
            </div>  
        </div>  
    </div>  
  
    <div th:if="${page != null && page.totalPages > 1}" class="pagination">  
        <a th:if="${page.hasPrevious()}"  
           th:href="@{/recipes(page=${page.number-1}, keyword=${keyword}, tag=${tag})}">이전</a>  
  
        <span th:text="'페이지 ' + (${page.number}+1) + ' / ' + ${page.totalPages}">페이지 1 / 1</span>  
  
        <a th:if="${page.hasNext()}"  
           th:href="@{/recipes(page=${page.number+1}, keyword=${keyword}, tag=${tag})}">다음</a>  
    </div>  
</div>  
</body>  
</html>

recipe_detail

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title th:text="${recipe?.title ?: '레시피 상세'}">레시피 상세</title>  
    <style>        
    body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }  
        .container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }  
  
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }  
        .back-btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; }  
        .back-btn:hover { background: #f8f8f8; }  
  
        .recipe-title { font-size: 1.8em; font-weight: bold; color: #333; margin-bottom: 15px; }  
        .recipe-meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }  
        .recipe-author { color: #007bff; font-weight: bold; }  
        .recipe-description { line-height: 1.6; color: #555; background: #f8f9fa; padding: 20px; border-radius: 4px; margin-bottom: 20px; }  
  
        .tags-section { margin-bottom: 20px; }  
        .tags-label { font-weight: bold; margin-bottom: 10px; }  
        .tag { display: inline-block; background: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.8em; margin-right: 5px; }  
  
        .like-section { text-align: center; margin: 30px 0; padding: 20px; background: #f8f9fa; border-radius: 4px; }  
        .btn-like { background: #dc3545; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }  
        .btn-like:hover { background: #c82333; }  
        .btn-unlike { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }  
        .btn-unlike:hover { background: #0056b3; }  
  
        .comments-section { margin-top: 30px; }  
        .comments-title { font-size: 1.2em; font-weight: bold; margin-bottom: 20px; border-bottom: 1px solid #ddd; padding-bottom: 10px; }  
        .comment { background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #007bff; }  
        .comment-header { font-weight: bold; margin-bottom: 5px; }  
        .comment-meta { color: #666; font-size: 0.8em; }  
        .comment-content { margin: 10px 0; }  
  
        .comment-form { background: #f8f9fa; padding: 20px; border-radius: 4px; margin-top: 20px; }  
        .comment-form textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; resize: vertical; }  
        .comment-form button { background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-top: 10px; }  
        .comment-form button:hover { background: #218838; }  
    </style>  
</head>  
<body>  
<div class="container">  
    <div class="header">  
        <h1>레시피 상세</h1>  
        <a th:href="@{/recipes}" class="back-btn">목록으로</a>  
    </div>  
  
    <h2 class="recipe-title" th:text="${recipe?.title ?: '제목 정보 없음'}">레시피 제목</h2>  
    <div class="recipe-meta">  
        <div>작성자: <span class="recipe-author" th:text="${recipe?.author?.username ?: '익명'}">작성자</span></div>  
        <div>작성일: <span th:text="${recipe?.createdAt != null ? #temporals.format(recipe.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span></div>  
    </div>  
  
    <div class="recipe-description" th:text="${recipe?.description ?: '설명이 없습니다.'}">레시피 설명</div>  
  
    <div class="tags-section" th:if="${recipe?.tags != null and !#lists.isEmpty(recipe.tags)}">  
        <div class="tags-label">태그:</div>  
        <span th:each="tag : ${recipe.tags}" class="tag" th:text="${tag?.name ?: '태그'}">태그</span>  
    </div>  
  
    <div class="like-section" th:if="${user != null}">  
        <form th:action="@{/recipes/{id}/like(id=${recipe?.id})}" method="post" style="display: inline;">  
            <button type="submit"  
                    th:class="${recipe?.likes != null && #lists.contains(recipe.likes, user) ? 'btn-unlike' : 'btn-like'}"  
                    th:text="${recipe?.likes != null && #lists.contains(recipe.likes, user) ? '좋아요 취소' : '좋아요'}">좋아요</button>  
        </form>  
        <span th:text="${recipe?.likes != null ? recipe.likes.size() : 0} + '명이 좋아합니다'">0명이 좋아합니다</span>  
    </div>  
  
    <div class="like-section" th:if="${user == null}">  
        <span th:text="${recipe?.likes != null ? recipe.likes.size() : 0} + '명이 좋아합니다'">0명이 좋아합니다</span>  
        <p><a th:href="@{/login}">로그인</a>하시면 좋아요를 누를 수 있습니다.</p>  
    </div>  
  
    <div class="comments-section">  
        <h3 class="comments-title">댓글</h3>  
  
        <div th:if="${recipe?.comments == null or #lists.isEmpty(recipe.comments)}">  
            <p>아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!</p>  
        </div>  
  
        <div th:each="comment : ${recipe?.comments ?: {}}" class="comment">  
            <div class="comment-header">  
                <span th:text="${comment?.user?.username ?: '익명'}">사용자</span>  
                <span class="comment-meta" th:text="${comment?.createdAt != null ? #temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span>  
            </div>  
            <div class="comment-content" th:text="${comment?.content ?: '내용이 없습니다.'}">댓글 내용</div>  
        </div>  
  
        <div class="comment-form" th:if="${user != null}">  
            <form th:action="@{/recipes/{id}/comments(id=${recipe?.id})}" method="post">  
                <textarea name="content" rows="3" placeholder="댓글을 작성해주세요..." required></textarea>  
                <button type="submit">댓글 작성</button>  
            </form>  
        </div>  
  
        <div class="comment-form" th:if="${user == null}">  
            <p><a th:href="@{/login}">로그인</a>하시면 댓글을 작성할 수 있습니다.</p>  
        </div>  
    </div>  
</div>  
</body>  
</html>

recipe_form

<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="UTF-8">  
    <title>새 레시피 등록</title>  
    <style>        
    body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }  
        .container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }  
  
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }  
        .back-btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; }  
        .back-btn:hover { background: #f8f8f8; }  
  
        .form-group { margin-bottom: 20px; }  
        .form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }  
        .form-group input, .form-group textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }  
        .form-group textarea { resize: vertical; min-height: 120px; }  
  
        .btn-submit { background: #28a745; color: white; border: none; padding: 12px 30px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; }  
        .btn-submit:hover { background: #218838; }  
  
        .form-actions { text-align: center; margin-top: 30px; }  
    </style>  
</head>  
<body>  
<div class="container">  
    <div class="header">  
        <h1>새 레시피 등록</h1>  
        <a th:href="@{/recipes}" class="back-btn">목록으로</a>  
    </div>  
  
    <form th:action="@{/recipes}" method="post">  
        <div class="form-group">  
            <label for="title">레시피 제목 *</label>  
            <input type="text" id="title" name="title" required placeholder="레시피 제목을 입력하세요"/>  
        </div>  
  
        <div class="form-group">  
            <label for="description">레시피 설명 *</label>  
            <textarea id="description" name="description" required placeholder="레시피 만드는 방법을 자세히 설명해주세요"></textarea>  
        </div>  
  
        <div class="form-group">  
            <label for="tags">태그</label>  
            <input type="text" id="tags" name="tags" placeholder="태그를 쉼표로 구분해서 입력하세요 (예: 한식, 간단요리, 밥요리)"/>  
        </div>  
  
        <div class="form-actions">  
            <button type="submit" class="btn-submit">레시피 등록</button>  
        </div>  
    </form>  
</div>  
</body>  
</html>

테스트

springboot_recipe 1.png

springboot_recipe 2.png

springboot_recipe 3.png

springboot_recipe 4.png

springboot_recipe 5.png

springboot_recipe 6.png

springboot_recipe 7.png

springboot_recipe 8.png